﻿using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace JESAI.DistributedLock.FileSystem;

/// <summary>
/// 用于验证文件名并将锁名转换为有效文件名的辅助类。
/// </summary>
internal static class FileNameValidationHelper
{
    // NOTE: 我们的目标是在可能的情况下确保跨平台的行为一致，以防有人锁定网络文件系统文件. 
    //
    // 这意味着我们只在名称中使用字母、数字和下划线，并且我们总是包含一个哈希组件（这避免了各种Windows“特殊”文件名。
    //
    // 长度是我们必须考虑的另一件事；在Windows上，我们处理的最大文件名长度为255个字符，最大路径长度为259个字符或32000个字符，具体取决于是否启用了长路径以及我们是否启用了。NETCore或。NETFramework。在unix上，我们期望文件名限制为255字节，路径限制为4096字节。
    // 
    // 另一个区别是区分大小写：在Windows上，文件名不区分大小写，但我们希望锁名区分大小写。
    //
    // 为了可移植性，我们更喜欢使用64个字符的固定名称长度，格式为[clean base name prefix][hash].lock。“clean base name prefix”是原始名称的前缀，其中非字母/数字/下划线字符被下划线替换。前缀尽可能长，不超过整体名称限制。“hash”是所提供名称的UTF8字节的32进制哈希。这为我们提供了区分大小写的能力，如果名称在“清理”过程中被截断或突变，碰撞的可能性很小。最后，“.lock”扩展是一个有用的指示器；这和名称前缀都是为了帮助调试。
    //
    // 有时，我们的基本目录太长，以至于我们无法使用这种完整的可移植名称格式。在这种情况下，我们回到哈希组件。
    // 如果这仍然太长，我们只能回到哈希的前12个字符。如果时间太长，我们就放弃。

    // 之所以选择它，是因为低于此值，碰撞的风险似乎太高了。如果有人选择的基目录太长，以至于名称限制在<12个字符以内，那感觉就像是一个错误。
    internal const int MinFileNameLength = 12;

    // 之所以选择此项，是因为它既小于Windows允许的255个字符，也小于Unix允许的255字节（因为我们总是包含Base 32哈希）。希望这也足够短，即使在Windows上也很少溢出MAX_PATH
    private const int PortableFileNameLength = 64;

    public static string GetLockFileName(DirectoryInfo lockFileDirectory, string name)
    {
        if (lockFileDirectory == null) { throw new ArgumentNullException(nameof(lockFileDirectory)); }
        if (name == null) { throw new ArgumentNullException(nameof(name)); }

        var directoryPath = lockFileDirectory.FullName;
        var directoryPathWithTrailingSeparator = directoryPath[directoryPath.Length - 1] == Path.DirectorySeparatorChar
            ? directoryPath
            : directoryPath + Path.DirectorySeparatorChar;

        var baseName = ConvertToValidBaseName(name);
        var nameHash = ComputeHash(Encoding.UTF8.GetBytes(name));
        const string Extension = ".lock";

        //首先，尝试完整的可移植名称格式
        var portableLockFileName = directoryPathWithTrailingSeparator
            + baseName.Substring(0, Math.Min(PortableFileNameLength - nameHash.Length - Extension.Length, baseName.Length))
            + nameHash
            + Extension;
        if (!IsTooLong(portableLockFileName))
        {
            return portableLockFileName;
        }

        // 接下来，尝试仅使用哈希值作为名称
        var hashOnlyFileName = directoryPathWithTrailingSeparator + nameHash;
        if (!IsTooLong(hashOnlyFileName))
        {
            return hashOnlyFileName;
        }

        // 最后，尝试只使用哈希的一部分
        var minimumLengthFileName = directoryPathWithTrailingSeparator + nameHash.Substring(0, MinFileNameLength);
        if (!IsTooLong(minimumLengthFileName))
        {
            return minimumLengthFileName;
        }

        throw new PathTooLongException($"Unable to construct lock file name because the base directory (length = {directoryPathWithTrailingSeparator.Length}) does not leave enough room for a {MinFileNameLength} lock name");
    }

    private static bool IsTooLong(string name)
    {
        try
        {
            Path.GetFullPath(name);
            return false;
        }
        catch (PathTooLongException)
        {
            return true;
        }
    }

    private static string ConvertToValidBaseName(string name)
    {
        const char ReplacementChar = '_';

        StringBuilder? builder = null;
        for (var i = 0; i < name.Length; ++i)
        {
            var @char = name[i];
            if (!char.IsLetterOrDigit(@char) && @char != ReplacementChar)
            {
                builder ??= new StringBuilder(name.Length).Append(name, startIndex: 0, count: i);
                builder.Append(ReplacementChar);
            }
            else if (builder != null)
            {
                builder.Append(@char);
            }
        }

        return builder?.ToString() ?? name;
    }

    // 我们截断为160位，即Base32的32个字符。这仍然可以为我们提供良好的抗冲突性，但允许64个字符的名称包含原始提供名称的大部分，这有利于调试。请参阅
    // https://crypto.stackexchange.com/questions/9435/is-truncating-a-sha512-hash-to-the-first-160-bits-as-secure-as-using-sha1#:~:text=Yes.,time%20is%20still%20pretty%20big
    private const int Base32CharBits = 5;
    internal const int HashLengthInChars = 160 / Base32CharBits;

    private static string ComputeHash(byte[] bytes)
    {
        using var sha = SHA512.Create();
        var hashBytes = sha.ComputeHash(bytes);

        // 我们使用Base32是因为它不区分大小写（对windows文件很重要），并且比Base16 RFC 4648更紧凑 https://en.wikipedia.org/wiki/Base32
        const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

        var chars = new char[HashLengthInChars];
        var byteIndex = 0;
        var bitBuffer = 0;
        var bitsRemaining = 0;
        for (var charIndex = 0; charIndex < chars.Length; ++charIndex)
        {
            if (bitsRemaining < Base32CharBits)
            {
                bitBuffer |= hashBytes[byteIndex++] << bitsRemaining;
                bitsRemaining += 8;
            }
            chars[charIndex] = Base32Alphabet[bitBuffer & 31];
            bitBuffer >>= Base32CharBits;
            bitsRemaining -= Base32CharBits;
        }

        return new string(chars);
    }
}
